
package com.rrd.dw.azkaban.flow;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Logger;

import com.rrd.dw.azkaban.expection.UndefinedPropertyException;

/**
 * Hashmap implementation of a hierarchitical properties with helpful converter
 * functions and Exception throwing. This class is not threadsafe.
 */
public class Props {
    private final Map<String, String> _current;
    private Props _parent;
    private String source = null;

    /**
     * Constructor for empty props with empty parent.
     */
    public Props() {
        this(null);
    }

    /**
     * Constructor for empty Props with parent override.
     *
     * @param parent
     */
    public Props(Props parent) {
        this._current = new HashMap<>();
        this._parent = parent;
    }

    /**
     * Load props from a file.
     *
     * @param parent
     * @param file
     * @throws IOException
     */
    public Props(Props parent, String filepath) throws IOException {
        this(parent, new File(filepath));
    }

    /**
     * Load props from a file.
     *
     * @param parent
     * @param file
     * @throws IOException
     */
    public Props(Props parent, File file) throws IOException {
        this(parent);
        this.setSource(file.getPath());

        InputStream input = new BufferedInputStream(new FileInputStream(file));
        try {
            this.loadFrom(input);
        } catch (IOException e) {
            throw e;
        } finally {
            input.close();
        }
    }

    /**
     * Create props from property input streams
     *
     * @param parent
     * @param inputStreams
     * @throws IOException
     */
    public Props(Props parent, InputStream inputStream) throws IOException {
        this(parent);
        this.loadFrom(inputStream);
    }

    /**
     * @param inputStream
     * @throws IOException
     */
    private void loadFrom(InputStream inputStream) throws IOException {
        Properties properties = new Properties();
        properties.load(inputStream);
        this.put(properties);
    }

    /**
     * Create properties from maps of properties
     *
     * @param parent
     * @param props
     */
    public Props(Props parent, Map<String, String>... props) {
        this(parent);
        for (int i = props.length - 1; i >= 0; i--) {
            this.putAll(props[i]);
        }
    }

    /**
     * Create properties from Properties objects
     *
     * @param parent
     * @param properties
     */
    public Props(Props parent, Properties... properties) {
        this(parent);
        for (int i = properties.length - 1; i >= 0; i--) {
            this.put(properties[i]);
        }
    }

    /**
     * Create a Props object with the contents set to that of props.
     *
     * @param parent
     * @param props
     */
    public Props(Props parent, Props props) {
        this(parent);
        if (props != null) {
            this.putAll(props);
        }
    }

    public void setEarliestAncestor(Props parent) {
        Props props = this.getEarliestAncestor();
        props.setParent(parent);
    }

    public Props getEarliestAncestor() {
        if (this._parent == null) {
            return this;
        }

        return this._parent.getEarliestAncestor();
    }

    /**
     * Create a Props with a null parent from a list of key value pairing. i.e.
     * [key1, value1, key2, value2 ...]
     *
     * @param args
     * @return
     */
    public static Props of(String... args) {
        return Props.of((Props) null, args);
    }

    /**
     * Create a Props from a list of key value pairing. i.e. [key1, value1,
     * key2,
     * value2 ...]
     *
     * @param args
     * @return
     */
    @SuppressWarnings("unchecked")
    public static Props of(Props parent, String... args) {
        if (args.length % 2 != 0) {
            throw new IllegalArgumentException(
                "Must have an equal number of keys and values.");
        }

        Map<String, String> vals = new HashMap<>(args.length / 2);

        for (int i = 0; i < args.length; i += 2) {
            vals.put(args[i], args[i + 1]);
        }
        return new Props(parent, vals);
    }

    /**
     * Clear the current Props, but leaves the parent untouched.
     */
    public void clearLocal() {
        this._current.clear();
    }

    /**
     * Check key in current Props then search in parent
     *
     * @param k
     * @return
     */
    public boolean containsKey(Object k) {
        return this._current.containsKey(k)
            || this._parent != null && this._parent.containsKey(k);
    }

    /**
     * Check value in current Props then search in parent
     *
     * @param value
     * @return
     */
    public boolean containsValue(Object value) {
        return this._current.containsValue(value)
            || this._parent != null && this._parent.containsValue(value);
    }

    /**
     * Return value if available in current Props otherwise return from parent
     *
     * @param key
     * @return
     */
    public String get(Object key) {
        if (this._current.containsKey(key)) {
            return this._current.get(key);
        } else if (this._parent != null) {
            return this._parent.get(key);
        } else {
            return null;
        }
    }

    /**
     * Get the key set from the current Props
     *
     * @return
     */
    public Set<String> localKeySet() {
        return this._current.keySet();
    }

    /**
     * Get parent Props
     *
     * @return
     */
    public Props getParent() {
        return this._parent;
    }

    /**
     * Put the given string value for the string key. This method performs any
     * variable substitution in the value replacing any occurance of ${name}
     * with
     * the value of get("name").
     *
     * @param key
     *            The key to put the value to
     * @param value
     *            The value to do substitution on and store
     * @throws IllegalArgumentException
     *             If the variable given for substitution is
     *             not a valid key in this Props.
     */
    public String put(String key, String value) {
        return this._current.put(key, value);
    }

    /**
     * Put the given Properties into the Props. This method performs any
     * variable
     * substitution in the value replacing any occurrence of ${name} with the
     * value of get("name"). get() is called first on the Props and next on the
     * Properties object.
     *
     * @param properties
     *            The properties to put
     * @throws IllegalArgumentException
     *             If the variable given for substitution is
     *             not a valid key in this Props.
     */
    public void put(Properties properties) {
        for (String propName : properties.stringPropertyNames()) {
            this._current.put(propName, properties.getProperty(propName));
        }
    }

    /**
     * Put integer
     *
     * @param key
     * @param value
     * @return
     */
    public String put(String key, Integer value) {
        return this._current.put(key, value.toString());
    }

    /**
     * Put Long. Stores as String.
     *
     * @param key
     * @param value
     * @return
     */
    public String put(String key, Long value) {
        return this._current.put(key, value.toString());
    }

    /**
     * Put Double. Stores as String.
     *
     * @param key
     * @param value
     * @return
     */
    public String put(String key, Double value) {
        return this._current.put(key, value.toString());
    }

    /**
     * Put everything in the map into the props.
     *
     * @param m
     */
    public void putAll(Map<? extends String, ? extends String> m) {
        if (m == null) {
            return;
        }

        for (Map.Entry<? extends String, ? extends String> entry : m
            .entrySet()) {
            this.put(entry.getKey(), entry.getValue());
        }
    }

    /**
     * Put all properties in the props into the current props. Will handle null
     * p.
     *
     * @param p
     */
    public void putAll(Props p) {
        if (p == null) {
            return;
        }

        for (String key : p.getKeySet()) {
            this.put(key, p.get(key));
        }
    }

    /**
     * Puts only the local props from p into the current properties
     *
     * @param p
     */
    public void putLocal(Props p) {
        for (String key : p.localKeySet()) {
            this.put(key, p.get(key));
        }
    }

    /**
     * Remove only the local value of key s, and not the parents.
     *
     * @param s
     * @return
     */
    public String removeLocal(Object s) {
        return this._current.remove(s);
    }

    /**
     * The number of unique keys defined by this Props and all parent Props
     */
    public int size() {
        return this.getKeySet().size();
    }

    /**
     * The number of unique keys defined by this Props (keys defined only in
     * parent Props are not counted)
     */
    public int localSize() {
        return this._current.size();
    }

    /**
     * Attempts to return the Class that corresponds to the Props value. If the
     * class doesn't exit, an IllegalArgumentException will be thrown.
     *
     * @param key
     * @return
     */
    public Class<?> getClass(String key) {
        try {
            if (this.containsKey(key)) {
                return Class.forName(this.get(key));
            } else {
                throw new UndefinedPropertyException(
                    "Missing required property '" + key + "'");
            }
        } catch (ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }

    public Class<?> getClass(String key, boolean initialize, ClassLoader cl) {
        try {
            if (this.containsKey(key)) {
                return Class.forName(this.get(key), initialize, cl);
            } else {
                throw new UndefinedPropertyException(
                    "Missing required property '" + key + "'");
            }
        } catch (ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }

    /**
     * Gets the class from the Props. If it doesn't exist, it will return the
     * defaultClass
     *
     * @param key
     * @param c
     * @return
     */
    public Class<?> getClass(String key, Class<?> defaultClass) {
        if (this.containsKey(key)) {
            return this.getClass(key);
        } else {
            return defaultClass;
        }
    }

    /**
     * Gets the string from the Props. If it doesn't exist, it will return the
     * defaultValue
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public String getString(String key, String defaultValue) {
        if (this.containsKey(key)) {
            return this.get(key);
        } else {
            return defaultValue;
        }
    }

    /**
     * Gets the string from the Props. If it doesn't exist, throw and
     * UndefinedPropertiesException
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public String getString(String key) {
        if (this.containsKey(key)) {
            return this.get(key);
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + key + "'");
        }
    }

    /**
     * Returns a list of strings with the comma as the separator of the value
     *
     * @param key
     * @return
     */
    public List<String> getStringList(String key) {
        return this.getStringList(key, "\\s*,\\s*");
    }

    /**
     * Returns a list of strings with the sep as the separator of the value
     *
     * @param key
     * @param sep
     * @return
     */
    public List<String> getStringList(String key, String sep) {
        String val = this.get(key);
        if (val == null || val.trim().length() == 0) {
            return Collections.emptyList();
        }

        if (this.containsKey(key)) {
            return Arrays.asList(val.split(sep));
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + key + "'");
        }
    }

    /**
     * Returns a list of strings with the comma as the separator of the value.
     * If
     * the value is null, it'll return the defaultValue.
     *
     * @param key
     * @return
     */
    public List<String> getStringList(String key, List<String> defaultValue) {
        if (this.containsKey(key)) {
            return this.getStringList(key);
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns a list of strings with the sep as the separator of the value. If
     * the value is null, it'll return the defaultValue.
     *
     * @param key
     * @return
     */
    public List<String> getStringList(String key, List<String> defaultValue,
            String sep) {
        if (this.containsKey(key)) {
            return this.getStringList(key, sep);
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns true if the value equals "true". If the value is null, then the
     * default value is returned.
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public boolean getBoolean(String key, boolean defaultValue) {
        if (this.containsKey(key)) {
            return "true".equalsIgnoreCase(this.get(key).trim());
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns true if the value equals "true". If the value is null, then an
     * UndefinedPropertyException is thrown.
     *
     * @param key
     * @return
     */
    public boolean getBoolean(String key) {
        if (this.containsKey(key)) {
            return "true".equalsIgnoreCase(this.get(key));
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + key + "'");
        }
    }

    /**
     * Returns the long representation of the value. If the value is null, then
     * the default value is returned. If the value isn't a long, then a parse
     * exception will be thrown.
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public long getLong(String name, long defaultValue) {
        if (this.containsKey(name)) {
            return Long.parseLong(this.get(name));
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns the long representation of the value. If the value is null, then
     * a
     * UndefinedPropertyException will be thrown. If the value isn't a long,
     * then
     * a parse exception will be thrown.
     *
     * @param key
     * @return
     */
    public long getLong(String name) {
        if (this.containsKey(name)) {
            return Long.parseLong(this.get(name));
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + name + "'");
        }
    }

    /**
     * Returns the int representation of the value. If the value is null, then
     * the
     * default value is returned. If the value isn't a int, then a parse
     * exception
     * will be thrown.
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public int getInt(String name, int defaultValue) {
        if (this.containsKey(name)) {
            return Integer.parseInt(this.get(name).trim());
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns the int representation of the value. If the value is null, then a
     * UndefinedPropertyException will be thrown. If the value isn't a int, then
     * a
     * parse exception will be thrown.
     *
     * @param key
     * @return
     */
    public int getInt(String name) {
        if (this.containsKey(name)) {
            return Integer.parseInt(this.get(name).trim());
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + name + "'");
        }
    }

    /**
     * Returns the double representation of the value. If the value is null,
     * then
     * the default value is returned. If the value isn't a double, then a parse
     * exception will be thrown.
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public double getDouble(String name, double defaultValue) {
        if (this.containsKey(name)) {
            return Double.parseDouble(this.get(name).trim());
        } else {
            return defaultValue;
        }
    }

    /**
     * Returns the double representation of the value. If the value is null,
     * then
     * a UndefinedPropertyException will be thrown. If the value isn't a double,
     * then a parse exception will be thrown.
     *
     * @param key
     * @return
     */
    public double getDouble(String name) {
        if (this.containsKey(name)) {
            return Double.parseDouble(this.get(name).trim());
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + name + "'");
        }
    }

    /**
     * Returns the uri representation of the value. If the value is null, then
     * the
     * default value is returned. If the value isn't a uri, then a
     * IllegalArgumentException will be thrown.
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public URI getUri(String name) {
        if (this.containsKey(name)) {
            try {
                return new URI(this.get(name));
            } catch (URISyntaxException e) {
                throw new IllegalArgumentException(e.getMessage());
            }
        } else {
            throw new UndefinedPropertyException(
                "Missing required property '" + name + "'");
        }
    }

    /**
     * Returns the double representation of the value. If the value is null,
     * then
     * the default value is returned. If the value isn't a uri, then a
     * IllegalArgumentException will be thrown.
     *
     * @param key
     * @param defaultValue
     * @return
     */
    public URI getUri(String name, URI defaultValue) {
        if (this.containsKey(name)) {
            return this.getUri(name);
        } else {
            return defaultValue;
        }
    }

    public URI getUri(String name, String defaultValue) {
        try {
            return this.getUri(name, new URI(defaultValue));
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e.getMessage());
        }
    }

    /**
     * Store only those properties defined at this local level
     *
     * @param file
     *            The file to write to
     * @throws IOException
     *             If the file can't be found or there is an io error
     */
    public void storeLocal(File file) throws IOException {
        BufferedOutputStream out = new BufferedOutputStream(
            new FileOutputStream(file));
        try {
            this.storeLocal(out);
        } finally {
            out.close();
        }
    }

    /**
     * Returns a copy of only the local values of this props
     *
     * @return
     */
    @SuppressWarnings("unchecked")
    public Props local() {
        return new Props(null, this._current);
    }

    /**
     * Store only those properties defined at this local level
     *
     * @param out
     *            The output stream to write to
     * @throws IOException
     *             If the file can't be found or there is an io error
     */
    public void storeLocal(OutputStream out) throws IOException {
        Properties p = new Properties();
        for (String key : this._current.keySet()) {
            p.setProperty(key, this.get(key));
        }
        p.store(out, null);
    }

    /**
     * Returns a java.util.Properties file populated with the stuff in here.
     *
     * @return
     */
    public Properties toProperties() {
        Properties p = new Properties();
        for (String key : this._current.keySet()) {
            p.setProperty(key, this.get(key));
        }

        return p;
    }

    /**
     * Store all properties, those local and also those in parent props
     *
     * @param file
     *            The file to store to
     * @throws IOException
     *             If there is an error writing
     */
    public void storeFlattened(File file) throws IOException {
        BufferedOutputStream out = new BufferedOutputStream(
            new FileOutputStream(file));
        try {
            this.storeFlattened(out);
        } finally {
            out.close();
        }
    }

    /**
     * Store all properties, those local and also those in parent props
     *
     * @param out
     *            The stream to write to
     * @throws IOException
     *             If there is an error writing
     */
    public void storeFlattened(OutputStream out) throws IOException {
        Properties p = new Properties();
        for (Props curr = this; curr != null; curr = curr.getParent()) {
            for (String key : curr.localKeySet()) {
                if (!p.containsKey(key)) {
                    p.setProperty(key, this.get(key));
                }
            }
        }

        p.store(out, null);
    }

    /**
     * Returns a map of all the flattened properties, the item in the returned
     * map is sorted alphabetically
     * by the key value.
     *
     * @Return
     */
    public Map<String, String> getFlattened() {
        TreeMap<String, String> returnVal = new TreeMap<>();
        returnVal.putAll(this.getMapByPrefix(""));
        return returnVal;
    }

    /**
     * Get a map of all properties by string prefix
     *
     * @param prefix
     *            The string prefix
     */
    public Map<String, String> getMapByPrefix(String prefix) {
        Map<String, String> values = this._parent == null ? new HashMap<>()
            : this._parent.getMapByPrefix(prefix);

        // when there is a conflict, value from the child takes the priority.
        for (String key : this.localKeySet()) {
            if (key.startsWith(prefix)) {
                values.put(key.substring(prefix.length()), this.get(key));
            }
        }
        return values;
    }

    /**
     * Returns a set of all keys, including the parents
     *
     * @return
     */
    public Set<String> getKeySet() {
        HashSet<String> keySet = new HashSet<>();

        keySet.addAll(this.localKeySet());

        if (this._parent != null) {
            keySet.addAll(this._parent.getKeySet());
        }

        return keySet;
    }

    /**
     * Logs the property in the given logger
     *
     * @param logger
     * @param comment
     */
    public void logProperties(Logger logger, String comment) {
        logger.info(comment);

        for (String key : this.getKeySet()) {
            logger.info("  key=" + key + " value=" + this.get(key));
        }
    }

    /**
     * Clones the Props p object and all of its parents.
     *
     * @param p
     * @return
     */
    public static Props clone(Props p) {
        return Props.copyNext(p);
    }

    /**
     * @param source
     * @return
     */
    private static Props copyNext(Props source) {
        Props priorNodeCopy = null;
        if (source.getParent() != null) {
            priorNodeCopy = Props.copyNext(source.getParent());
        }
        Props dest = new Props(priorNodeCopy);
        for (String key : source.localKeySet()) {
            dest.put(key, source.get(key));
        }

        return dest;
    }

    /**
     */
    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        } else if (o == null) {
            return false;
        } else if (o.getClass() != Props.class) {
            return false;
        }

        Props p = (Props) o;
        return this._current.equals(p._current)
            && Utils.equals(this._parent, p._parent);
    }

    /**
     * Returns true if the properties are equivalent, regardless of the
     * hierarchy.
     *
     * @param p
     * @return
     */
    public boolean equalsProps(Props p) {
        if (p == null) {
            return false;
        }

        final Set<String> myKeySet = this.getKeySet();
        for (String s : myKeySet) {
            if (!this.get(s).equals(p.get(s))) {
                return false;
            }
        }

        return myKeySet.size() == p.getKeySet().size();
    }

    /**
     *
     */
    @Override
    public int hashCode() {
        int code = this._current.hashCode();
        if (this._parent != null) {
            code += this._parent.hashCode();
        }
        return code;
    }

    /**
     *
     */
    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder("{");
        for (Map.Entry<String, String> entry : this._current.entrySet()) {
            builder.append(entry.getKey());
            builder.append(": ");
            builder.append(entry.getValue());
            builder.append(", ");
        }
        if (this._parent != null) {
            builder.append(" parent = ");
            builder.append(this._parent.toString());
        }
        builder.append("}");
        return builder.toString();
    }

    public String getSource() {
        return this.source;
    }

    public void setSource(String source) {
        this.source = source;
    }

    public void setParent(Props prop) {
        this._parent = prop;
    }
}
